You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Memory Management and Garbage Collection in .NET

Memory<T> and ReadOnlyMemory<T>

Heap-safe counterparts to Span for async and storage scenarios

Memory and ReadOnlyMemory

Master modern .NET memory allocation with free flashcards and spaced repetition practice. This lesson covers Memory and ReadOnlyMemory primitives, Span relationships, and zero-copy techniquesβ€”essential concepts for building high-performance .NET applications that minimize heap allocations and garbage collection pressure.

πŸ’» Memory and ReadOnlyMemory are modern allocation primitives introduced in .NET Core 2.1 that represent contiguous regions of memory without requiring that memory to live on the managed heap. They're the "storable" cousins of Span and ReadOnlySpan, designed to work in asynchronous scenarios and as class fields where ref structs cannot.

Welcome to Modern Memory Management

Traditional .NET development relied heavily on arrays and strings, which always allocate on the heap and create garbage collection pressure. When you needed to pass around subsets of data, you'd either copy the data (expensive) or pass indices around (error-prone). Memory and ReadOnlyMemory solve these problems by providing a unified abstraction over different memory sources while supporting zero-copy slicing operations.

πŸ”Ί Think of Memory as a "bookmark" to a contiguous region of memory. Just like a bookmark doesn't contain the book's pages but tells you where to find them, Memory doesn't contain the data itselfβ€”it's a lightweight wrapper that points to memory that could be:

  • A managed array on the heap
  • Native memory allocated outside the GC
  • Memory-mapped files
  • Stack-allocated memory (when converted from Span)

Core Concepts

What Are Memory and ReadOnlyMemory?

Memory is a value type (struct) that represents a contiguous region of arbitrary memory, similar to ArraySegment but far more powerful and flexible. Its read-only counterpart, ReadOnlyMemory, provides the same capabilities but prevents modification of the underlying data.

FeatureMemoryReadOnlyMemory
Storage locationStack (value type)Stack (value type)
Can be fieldβœ… Yesβœ… Yes
Async supportβœ… Yesβœ… Yes
Modificationβœ… Mutable❌ Read-only
GC trackingβœ… Yes (if managed)βœ… Yes (if managed)

Key Properties and Methods

Both types expose similar APIs:

Properties:

  • Length - Number of elements in the memory region
  • IsEmpty - Returns true if Length is 0
  • Span - Gets a Span or ReadOnlySpan over the memory

Methods:

  • Slice(start, length) - Creates a new Memory over a portion (zero-copy)
  • CopyTo(Memory<T>) - Copies contents to another memory region
  • Pin() - Returns a MemoryHandle for interop scenarios
  • ToArray() - Creates a new array copy of the data

The Relationship with Span

πŸ’‘ Critical distinction: Memory can be stored in fields and used in async methods, while Span cannot (it's a ref struct). When you need to actually work with the data, you convert Memory to Span via the .Span property.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         MEMORY TYPE HIERARCHY               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    Memory              ReadOnlyMemory
    (mutable)              (immutable)
        β”‚                         β”‚
        β”‚ .Span property          β”‚ .Span property
        ↓                         ↓
    Span                ReadOnlySpan
    (ref struct)           (ref struct)
        β”‚                         β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
                 ↓
         Actual data manipulation
         (indexing, iteration, etc.)

🧠 Memory device: Think "Memory can be Moved around" (stored anywhere), while "Span is Stuck on the stack" (ref struct limitations).

Creating Memory Instances

There are several ways to create Memory instances:

From arrays:

int[] numbers = { 1, 2, 3, 4, 5 };
Memory<int> memory = numbers;  // Implicit conversion
Memory<int> slice = numbers.AsMemory(1, 3);  // Elements [1,2,3]

From strings:

string text = "Hello, World!";
ReadOnlyMemory<char> memory = text.AsMemory();
ReadOnlyMemory<char> hello = text.AsMemory(0, 5);  // "Hello"

Using MemoryPool:

using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024);
Memory<byte> memory = owner.Memory;
// Use memory...
// Automatically returned when owner is disposed

From ArrayPool:

byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
Memory<byte> memory = rented.AsMemory(0, 1024);
// Use memory...
ArrayPool<byte>.Shared.Return(rented);

Zero-Copy Slicing Operations

One of the most powerful features is slicingβ€”creating views over subsets of memory without copying data:

int[] data = { 10, 20, 30, 40, 50, 60, 70, 80 };
Memory<int> memory = data;

// All these create views, NO copying occurs
Memory<int> first4 = memory.Slice(0, 4);      // [10,20,30,40]
Memory<int> middle = memory.Slice(2, 4);      // [30,40,50,60]
Memory<int> last3 = memory.Slice(5);          // [60,70,80]

// Modifications through any slice affect the original
first4.Span[0] = 99;
Console.WriteLine(data[0]);  // Outputs: 99
Original array: [10β”‚20β”‚30β”‚40β”‚50β”‚60β”‚70β”‚80]
                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                    first4 slice
                        (no copy!)

Memory layout visualization:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  10 β”‚ 20 β”‚ 30 β”‚ 40 β”‚ 50 β”‚ 60 β”‚ 70 β”‚ 80 β”‚  ← Actual data
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   ↑                                    ↑
   β”‚                                    β”‚
 memory (entire array)          memory.Slice(5)
   β”‚                             points here
   └─ first4.Span[0] = 99 modifies this

Working with ReadOnlyMemory

ReadOnlyMemory is essential for immutable data and API contracts that shouldn't allow modification:

public class DataProcessor
{
    private readonly ReadOnlyMemory<byte> _data;
    
    public DataProcessor(byte[] data)
    {
        // Defensive copy NOT needed - ReadOnlyMemory prevents mutation
        _data = data;
    }
    
    public int CalculateChecksum()
    {
        ReadOnlySpan<byte> span = _data.Span;
        int checksum = 0;
        foreach (byte b in span)
        {
            checksum ^= b;
        }
        return checksum;
    }
}

πŸ”’ Immutability guarantee: Consumers cannot modify data through ReadOnlyMemory even if the original source is mutable. This makes it perfect for caching, sharing data across threads, or public APIs.

Detailed Examples

Example 1: High-Performance String Parsing

Traditional string parsing creates many temporary strings (garbage). Memory enables zero-allocation parsing:

public static class CsvParser
{
    public static List<ReadOnlyMemory<char>> ParseLine(ReadOnlyMemory<char> line)
    {
        var fields = new List<ReadOnlyMemory<char>>();
        int start = 0;
        ReadOnlySpan<char> span = line.Span;
        
        for (int i = 0; i < span.Length; i++)
        {
            if (span[i] == ',')
            {
                // Slice creates a view - no string allocation!
                fields.Add(line.Slice(start, i - start));
                start = i + 1;
            }
        }
        
        // Don't forget the last field
        if (start < span.Length)
        {
            fields.Add(line.Slice(start));
        }
        
        return fields;
    }
}

// Usage:
string csvLine = "John,Doe,42,Engineer";
ReadOnlyMemory<char> memory = csvLine.AsMemory();
List<ReadOnlyMemory<char>> fields = CsvParser.ParseLine(memory);

// Convert to string only when needed
string firstName = fields[0].ToString();  // "John"
string age = fields[2].ToString();        // "42"

Performance benefit: Traditional Split(',') creates 4 string objects on the heap. This approach creates zero heap allocations until you explicitly call ToString().

Example 2: Async I/O with Memory

Memory shines in async scenarios where Span cannot be used:

public class AsyncFileProcessor
{
    private readonly Memory<byte> _buffer;
    
    public AsyncFileProcessor(int bufferSize = 4096)
    {
        _buffer = new byte[bufferSize];
    }
    
    public async Task<int> ReadAndProcessAsync(Stream stream)
    {
        // Memory<T> works in async methods - Span<T> does NOT!
        int bytesRead = await stream.ReadAsync(_buffer);
        
        if (bytesRead == 0)
            return 0;
        
        // Now convert to Span for actual processing
        Span<byte> dataToProcess = _buffer.Span.Slice(0, bytesRead);
        
        // Process the data
        int processedCount = ProcessBytes(dataToProcess);
        
        return processedCount;
    }
    
    private int ProcessBytes(Span<byte> data)
    {
        int count = 0;
        for (int i = 0; i < data.Length; i++)
        {
            if (data[i] > 127)  // Example: count non-ASCII bytes
                count++;
        }
        return count;
    }
}

⚑ Key insight: Memory bridges the gap between async methods (where ref structs can't live) and high-performance Span operations.

ScenarioUse ThisWhy
Async methodMemoryCan survive await points
Synchronous processingSpanSlightly faster, stack-only
Class fieldMemoryRef structs can't be fields
Local variableEitherBoth work; Span slightly preferred

Example 3: Memory Pooling for Reduced GC Pressure

Combining Memory with pooling eliminates allocations entirely:

public class PacketProcessor
{
    public async Task ProcessPacketsAsync(NetworkStream stream)
    {
        // Rent from shared pool instead of allocating
        using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(2048);
        Memory<byte> buffer = owner.Memory;
        
        while (true)
        {
            // Read packet header
            int headerBytes = await stream.ReadAsync(buffer.Slice(0, 4));
            if (headerBytes == 0) break;
            
            // Parse packet length from header
            int packetLength = BitConverter.ToInt32(buffer.Span.Slice(0, 4));
            
            if (packetLength > buffer.Length - 4)
            {
                throw new InvalidOperationException("Packet too large");
            }
            
            // Read packet body
            int bodyBytes = await stream.ReadAsync(
                buffer.Slice(4, packetLength));
            
            // Process the complete packet
            ProcessPacket(buffer.Span.Slice(0, 4 + packetLength));
        }
        // Memory automatically returned to pool on dispose
    }
    
    private void ProcessPacket(ReadOnlySpan<byte> packet)
    {
        // Packet processing logic
        Console.WriteLine($"Processed packet of {packet.Length} bytes");
    }
}

πŸ“Š Performance impact:

  • Traditional approach: Allocates array per packet β†’ frequent GC
  • Pooled approach: Reuses memory β†’ minimal GC, 2-5x faster throughput

Example 4: Slicing Protocol Messages

Parsing binary protocols becomes elegant with Memory:

public readonly struct HttpHeader
{
    public ReadOnlyMemory<char> Name { get; }
    public ReadOnlyMemory<char> Value { get; }
    
    public HttpHeader(ReadOnlyMemory<char> name, ReadOnlyMemory<char> value)
    {
        Name = name;
        Value = value;
    }
}

public static class HttpParser
{
    public static List<HttpHeader> ParseHeaders(ReadOnlyMemory<char> headerBlock)
    {
        var headers = new List<HttpHeader>();
        ReadOnlyMemory<char> remaining = headerBlock;
        
        while (!remaining.IsEmpty)
        {
            // Find line break
            ReadOnlySpan<char> span = remaining.Span;
            int lineEnd = span.IndexOf('\n');
            if (lineEnd == -1) break;
            
            ReadOnlyMemory<char> line = remaining.Slice(0, lineEnd);
            remaining = remaining.Slice(lineEnd + 1);
            
            // Find colon separator
            ReadOnlySpan<char> lineSpan = line.Span;
            int colonPos = lineSpan.IndexOf(':');
            if (colonPos == -1) continue;
            
            // Split into name and value (zero-copy!)
            ReadOnlyMemory<char> name = line.Slice(0, colonPos);
            ReadOnlyMemory<char> value = line.Slice(colonPos + 1).Trim();
            
            headers.Add(new HttpHeader(name, value));
        }
        
        return headers;
    }
}

// Usage:
string rawHeaders = "Content-Type: application/json\nContent-Length: 1234\n";
ReadOnlyMemory<char> headerMemory = rawHeaders.AsMemory();
List<HttpHeader> headers = HttpParser.ParseHeaders(headerMemory);

foreach (var header in headers)
{
    // Convert to string only when displaying
    Console.WriteLine($"{header.Name}: {header.Value}");
}

🎯 Design principle: Keep data as Memory as long as possible. Only convert to concrete types (strings, arrays) at the boundaries of your system.

Common Mistakes

⚠️ Mistake 1: Holding Memory After Source Is Disposed

// ❌ WRONG - Dangerous!
Memory<byte> GetBuffer()
{
    using var owner = MemoryPool<byte>.Shared.Rent(1024);
    return owner.Memory;  // Memory becomes invalid after method returns!
}

// βœ… RIGHT - Return the owner or copy the data
IMemoryOwner<byte> GetBuffer()
{
    return MemoryPool<byte>.Shared.Rent(1024);
    // Caller is responsible for disposal
}

⚠️ Mistake 2: Using Span When You Need Memory

// ❌ WRONG - Won't compile!
public class DataCache
{
    private Span<byte> _cachedData;  // Error: Can't use ref struct as field
}

// βœ… RIGHT - Use Memory<T> for fields
public class DataCache
{
    private Memory<byte> _cachedData;
    
    public void Cache(byte[] data)
    {
        _cachedData = data;
    }
}

⚠️ Mistake 3: Unnecessary Copying

// ❌ WRONG - Creates unnecessary copy
public void ProcessData(ReadOnlyMemory<char> input)
{
    string text = input.ToString();  // Allocates string
    char[] chars = text.ToCharArray();  // Another allocation!
    // Process chars...
}

// βœ… RIGHT - Work with Span directly
public void ProcessData(ReadOnlyMemory<char> input)
{
    ReadOnlySpan<char> span = input.Span;  // Zero allocation
    // Process span directly...
}

⚠️ Mistake 4: Forgetting ReadOnly Variants

// ❌ WRONG - Mutable when it should be immutable
public Memory<char> GetProductName()
{
    return _productName;  // Callers can modify!
}

// βœ… RIGHT - Use ReadOnly for immutable data
public ReadOnlyMemory<char> GetProductName()
{
    return _productName;  // Safe from modification
}

⚠️ Mistake 5: Pinning Without Disposal

// ❌ WRONG - Memory handle leaks
public void UseNativeApi(Memory<byte> data)
{
    MemoryHandle handle = data.Pin();
    // ... use handle.Pointer ...
    // Forgot to dispose!
}

// βœ… RIGHT - Always dispose MemoryHandle
public void UseNativeApi(Memory<byte> data)
{
    using MemoryHandle handle = data.Pin();
    // ... use handle.Pointer ...
}  // Automatically unpinned

Key Takeaways

βœ… Memory vs Span: Use Memory when you need to store in fields, pass to async methods, or keep references long-term. Use Span for immediate, synchronous processing.

βœ… Zero-copy operations: Slicing with .Slice() creates views over existing memory without copying dataβ€”critical for high-performance scenarios.

βœ… ReadOnly variants: Use ReadOnlyMemory and ReadOnlySpan to enforce immutability and communicate intent in APIs.

βœ… Pooling integration: Combine Memory with MemoryPool or ArrayPool to eliminate allocations and reduce GC pressure.

βœ… Async compatibility: Memory works seamlessly with async/await, while Span cannot cross await points.

βœ… Conversion pattern: Store as Memory β†’ convert to Span for processing β†’ convert back to concrete types only when necessary.

πŸ“‹ Quick Reference Card

TypeStorageAsyncMutable
Memory<T>Field/Propertyβœ… Yesβœ… Yes
ReadOnlyMemory<T>Field/Propertyβœ… Yes❌ No
Span<T>Stack only❌ Noβœ… Yes
ReadOnlySpan<T>Stack only❌ No❌ No

Common conversions:

  • array.AsMemory() β†’ Memory<T>
  • memory.Span β†’ Span<T>
  • memory.Slice(start, length) β†’ sliced Memory<T>
  • memory.ToArray() β†’ new array copy

When to use:

  • Memory<T>: Async methods, class fields, long-lived references
  • Span<T>: Synchronous processing, maximum performance
  • ReadOnly*: Immutable data, public APIs, thread-safe sharing

πŸ“š Further Study

  1. Microsoft Docs - Memory<T> and Span<T> usage guidelines: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines

  2. Stephen Toub's article on Memory<T> and Span<T>: https://learn.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay

  3. Adam Sitnik's performance benchmarks: https://adamsitnik.com/Span/

πŸ’‘ Pro tip: Use BenchmarkDotNet to measure the performance impact of Memory in your specific scenarios. The benefits are most dramatic in high-throughput applications processing large amounts of data.

πŸ”§ Try this: Convert an existing string parsing method to use ReadOnlyMemory instead of string.Split(). Measure the allocation reduction using a memory profilerβ€”you'll likely see 80%+ reduction in heap allocations!